前端常常要把一个镜像部署到不同的环境,每个环境设置的变量不一样。基于 pages 目录的路由系统,publicRuntimeConfig 对象可以被客户端和服务端访问到,在部署镜像时将环境变量塞到 publicRuntimeConfig 对象里就可以了。
但是在基于 app 目录的路由系统,官方废弃了 publicRuntimeConfig 功能,这让客户端访问运行时变量的方式突然变得复杂了。
幸亏我聪明,想到了一个绝佳的解决方案,核心做法是服务端托管一个脚本,在客户端嵌入这个脚本,当页面加载时会加载这个脚本。
声明
这个方法会在所有页面都注入运行时环境变量,如果你只想在某个路由注入,最好在服务端以 props 的形式,将运行时环境变量注入到客户端组件。
首先我们先新建一个路由处理文件 :/app/api/config/route.ts
,代码如下:
export async function GET(): Promise<Response> {
return new Response(
`window.__NEXT_PUBLIC_RUNTIME_CONFIG__ = ${process.env.PUBLIC_RUNTIME_KEY}`,
{
status: 200,
headers: {
"content-type": "application/javascript",
},
}
);
}
export const dynamic = "force-dynamic";
content-type
被设置成 application/javascript
,客户端会加载并执行 window.__NEXT_PUBLIC_RUNTIME_CONFIG__
变量的的赋值语句。
这样,我们在服务端托管了一个地址为 /api/config
的脚本,这个脚本会向客户端注入一个全局变量 __NEXT_PUBLIC_RUNTIME_CONFIG__
,这个变量后面会用到。
export const dynamic = "force-dynamic"
这句话非常重要。如果没有这句话,Nextjs 会在构建阶段,缓存这个路由 GET 方法的返回值,导致环境变量 process.env.PUBLIC_RUNTIME_KEY
始终是构建时的环境变量,而不是运行时的环境变量。
接下来我们要在页面中加载这个脚本,修改根布局文件 /app/layout.ts
:
export default function RootLayout({
children,
}: {
children: React.ReactNode,
}) {
return (
<html lang="zh-CN">
<head>
<script src="/api/config"></script>
</head>
<body>{children}</body>
</html>
);
}
注意
这里我们没有使用 nextjs 官方的 Script 组件,比如 <Script src="/api/config" strategy="beforeInteractive"></Script>
。
官方的对于strategy="beforeInteractive
的解释是,它尽早加载脚本,但是并不保证脚本在客户端 hydrate 之前执行。也就是说,在客户端 hydrate 的时候,有可能访问不到全局属性 __NEXT_PUBLIC_RUNTIME_CONFIG__
。
当我们在 k8s 集群上设置了环境变量 PUBLIC_RUNTIME_KEY
,会覆盖镜像中同名的环境变量 PUBLIC_RUNTIME_KEY
。
所以每次访问页面加载 /api/config
脚本时,/app/api/config/route.ts
路由处理程序都会实时获取 k8s 集群上设置的环境变量 PUBLIC_RUNTIME_KEY
并且塞到全局变量 __NEXT_PUBLIC_RUNTIME_CONFIG__
里面。
任何在客户端发生的操作,都可以通过全局变量 __NEXT_PUBLIC_RUNTIME_CONFIG__
访问到运行时环境变量,比如在点击按钮时,打印出变量 PUBLIC_RUNTIME_KEY
:
"use client";
export default function Page() {
return (
<button
onClick={() => {
console.log(window.__NEXT_PUBLIC_RUNTIME_CONFIG__.PUBLIC_RUNTIME_KEY);
}}
>
click me
</button>
);
}
很不幸,即使组件使用了 use client
指令,nextjs 也会在服务端尽可能地预渲染组件,然后在客户端进行 hydrate 操作。这会导致 Page 函数运行两次,第一次在服务端,第二次在客户端。在服务端预渲染的时候是不可以使用 window 对象。
比如我们在 Page 组件的第三行插入下面这行代码,在服务端预渲染时会报错,因为在服务端 window 是不存在的。
"use client";
export default function Page() {
console.log(window.__NEXT_PUBLIC_RUNTIME_CONFIG__.PUBLIC_RUNTIME_KEY);
return (
<button
onClick={() => {
console.log(window.__NEXT_PUBLIC_RUNTIME_CONFIG__.PUBLIC_RUNTIME_KEY);
}}
>
click me
</button>
);
}
为了解决这个问题,我们需要包装一下环境变量的获取逻辑。新建一个文件 /lib/getConfig.ts
,代码如下:
export default function getConfig() {
return typeof window === "undefined"
? { PUBLIC_RUNTIME_KEY: process.env.PUBLIC_RUNTIME_KEY }
: window.__NEXT_PUBLIC_RUNTIME_CONFIG__;
}
getConfig
函数会根据它被调用时的环境,自动返回需要的变量:在客户端调时就用全局变量 window.__NEXT_PUBLIC_RUNTIME_CONFIG__
,在服务端调用时就用 process.env
。
我们改造一下上面的 Page 组件:
"use client";
import getConfig from "@/lib/getConfig";
export default function Page() {
console.log(window.__NEXT_PUBLIC_RUNTIME_CONFIG__.PUBLIC_RUNTIME_KEY);
console.log(getConfig().PUBLIC_RUNTIME_KEY);
return (
<button
onClick={() => {
console.log(window.__NEXT_PUBLIC_RUNTIME_CONFIG__.PUBLIC_RUNTIME_KEY);
console.log(getConfig().PUBLIC_RUNTIME_KEY);
}}
>
click me
</button>
);
}
这样,不论在服务端预渲染,还是在客户端 hydrate,都能成功获取到环境变量 PUBLIC_RUNTIME_KEY
。
甚至在服务端组件,也可以这么用,比如:
import getConfig from "@/lib/getConfig";
export default function ServerComponent() {
console.log(getConfig().PUBLIC_RUNTIME_KEY);
return <h1>hello</h1>;
}
太棒了!通过这种包装,完美地统一了客户端和服务端获取环境变量的写法。
是不是特别像 pages 路由系统下 import getConfig from 'next/config'
的写法?其实next/config
的实现原理大概也就是这样,把环境变量塞到了客户端里面。
以上方案有个小问题,就是 <script src="/api/config"></script>
会阻碍页面的渲染,所以请不要在 /app/api/config/route.ts
路由处理程序中进行非常耗时的操作,否则会导致太长的白屏时间。